Skip to content

feat(audio): engine availability control and external call system session mode#1127

Merged
hiroshihorie merged 15 commits into
mainfrom
hiroshi/audio-engine-availability
Jul 21, 2026
Merged

feat(audio): engine availability control and external call system session mode#1127
hiroshihorie merged 15 commits into
mainfrom
hiroshi/audio-engine-availability

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Adds the two switches needed for CallKit coordination, mirroring the Swift SDK pattern (AudioManager.setEngineAvailability and AudioSessionEngineObserver.isAutomaticConfigurationEnabled).

Engine availability

// at startup, before connecting
await AudioManager.instance.setEngineAvailability(AudioEngineAvailability.none);

// in CallKit provider(didActivate:), for apps whose CallKit events arrive in Dart
// (e.g. flutter_callkit_incoming). Apps with a native CXProvider delegate should
// call the native static LiveKitPlugin.setEngineAvailability there instead.
await AudioManager.instance.setEngineAvailability(AudioEngineAvailability.defaultAvailability);

This is the highest priority gate over anything that may start the audio engine. Requests made while unavailable are honored once availability allows, so apps can call room.connect and setMicrophoneEnabled(true) inside CallKit action handlers and let the engine start when CallKit activates the session. No-op on non-Apple platforms, always safe from cross-platform code.

External call system session mode

await AudioManager.instance.setAudioSessionManagementMode(
  AudioSessionManagementMode.externalCallSystem,
);

LiveKit keeps configuring the session category/mode from engine lifecycle like automatic mode, but never calls setActive in either direction. CallKit owns activation timing. This improves on the Swift example flow, where the app must set the category itself inside didActivate.

The mode is named platform-neutrally on purpose. On Android it currently behaves like manual mode, and the same mode is reserved to stand down LiveKit's audio-focus and routing management when Telecom (androidx.core.telecom) integration lands, since the Telecom framework owns routing for registered calls.

Native early gating

LiveKitPlugin.setEngineAvailability(isInputAvailable:isOutputAvailable:) is a public static so an AppDelegate can gate audio before the Flutter engine exists (CallKit killed-state wake). The value is stored and applied at plugin registration, after the engine observer is installed and before any audio operation can start the engine.

Implementation notes

  • Implemented entirely on LiveKit's own plugin and method channel. The ADM API (RTCAudioDeviceModule.setEngineAvailability) is public in the WebRTC-SDK pod, so no flutter_webrtc change or release is needed.
  • The availability setter dispatches off the platform thread, since an availability flip can rebuild the audio engine.
  • One new observer flag (isSessionActivationEnabled) threads through the three activation sites: managed configure, cached configure, and the engine-disable deactivation path.
  • deactivateAudioSession() is a guarded no-op under externalCallSystem.

Testing

  • dart analyze lib test clean, all 344 tests pass (4 new tests for channel args and the external-mode deactivation guard).
  • iOS example builds.
  • A CallKit example app exercising the full flow (outgoing, incoming via PushKit, mute, end) is the next step in livekit-examples.

Follow-ups

  • CallKit example app update in livekit-examples/flutter-callkit.
  • Android Telecom (androidx.core.telecom) integration behind the same externalCallSystem mode.
  • docs/audio.md CallKit section.

…sion mode

Adds the two switches needed for CallKit coordination, mirroring the
Swift SDK pattern:

- AudioManager.setEngineAvailability/getEngineAvailability with a
  LiveKit-owned AudioEngineAvailability type. This is the highest
  priority gate over anything that may start the audio engine. Requests
  made while unavailable are honored once availability allows, so apps
  can connect and publish inside CallKit action handlers and let the
  engine start in provider(didActivate:). No-op on non-Apple platforms.
- AudioSessionManagementMode.externalCallSystem: LiveKit keeps
  configuring the session from engine lifecycle but never activates or
  deactivates it, since the external call system (CallKit) owns
  activation timing. Named platform-neutrally. On Android it currently
  behaves like manual, reserved to also stand down audio-focus
  management when Telecom integration lands.
- Native LiveKitPlugin.setEngineAvailability static API so an
  AppDelegate can gate audio before the Flutter engine exists (CallKit
  killed-state wake). The value is stored and applied at plugin
  registration.

Implemented entirely on LiveKit's own plugin/channel (the ADM API is
public in the WebRTC-SDK pod), so no flutter_webrtc release is needed.
On Android the mode currently behaves like automatic, not manual. The
Android session configure and speakerphone paths still run, since the
activation flag only exists on iOS. This is the intended interim
behavior: a cross-platform app sets the mode once and Android keeps
normal session management until Telecom integration lands.
@hiroshihorie
hiroshihorie marked this pull request as ready for review July 7, 2026 09:04
devin-ai-integration[bot]

This comment was marked as resolved.

- setInitialAudioSessionOptions now also seeds under externalCallSystem,
  which drives configuration from engine lifecycle like automatic mode.
  Previously the initialize-time options were silently dropped.
- Document that setEngineAvailability deliberately propagates platform
  errors, unlike the other Native methods. A failed availability change
  means the engine may run outside the intended CallKit window, so the
  error must reach the caller. Mirrors the Swift SDK's throwing API.
devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

@devin-ai-integration devin-ai-integration Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Audio session configuration not re-applied when switching from manual to external-call-system mode

The mode transition check (mode == AudioSessionManagementMode.automatic at lib/src/audio/audio_manager.dart:240) only re-applies the audio configuration when switching to automatic, so switching from manual to externalCallSystem while the engine is running leaves stale native configuration in place.

Impact: An app that switches from manual to external-call-system mode mid-session will keep the old audio session configuration until the next engine lifecycle event, causing incorrect audio routing or category.

Mechanism: the re-apply condition doesn't account for externalCallSystem being an automatic-configuration mode

Before this PR there were only two modes (automatic and manual), so the condition previousMode != automatic && mode == automatic correctly covered the only transition that needed a re-apply. With the new externalCallSystem mode, _isAutomaticConfigurationEnabled returns true for both automatic and externalCallSystem (lib/src/audio/audio_manager.dart:104). But the re-apply guard at line 240 still only checks for mode == AudioSessionManagementMode.automatic, missing the manual → externalCallSystem transition.

The fix should check whether the new mode is any automatic-configuration mode, e.g.:

if (previousMode == AudioSessionManagementMode.manual && _isAutomaticConfigurationEnabled) {

or equivalently check both automatic and externalCallSystem.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I think that's expected, mid-session switching is not really supported.

- The Dart channel path now records into the pending engine
  availability, so both the native static and the channel keep the
  latest intent. A later plugin registration (a second Flutter engine in
  the same process) re-applies the pending value and must not lag behind
  a Dart-side update.
- Document that deactivateAudioSession is disabled under
  externalCallSystem on all platforms, and scope the Android 'behaves
  like automatic' description to session configuration.
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Apps that set the gate know what they set. Dropping the getter halves
the exposed experimental surface. The native adm.engineAvailability
property remains readable from Swift for debugging.
…ystems

setConfiguration(_:active:) always forwards its active flag to
setActive, verified in WebRTC-SDK m144 (RTCAudioSession+Configuration.mm
line 34 and 129). Passing active: false therefore deactivated the
session CallKit had just activated on every engine lifecycle event,
silencing the call. Use the configure-only setConfiguration(_:) variant
when session activation belongs to the external call system.
The overrideOutputAudioPort call was guarded by isActive, which is the
activation ownership intent, not the actual session state. Under
externalCallSystem the session is active during a CallKit call, yet the
guard made setSpeakerOutputPreferred(force: true) a silent no-op. Apply
the override for the playAndRecord category regardless of ownership and
tolerate failures while the session is not active yet, where the next
engine lifecycle event re-applies it.
devin-ai-integration[bot]

This comment was marked as resolved.

…em mode

applyOptionsForConnect acquires the Android audio session in every mode
except manual, but the disconnect path still gated the release on
automatic mode only. Under externalCallSystem, Android kept audio focus
and communication mode held after leaving a room. Mirror the acquire
predicate so acquire and release stay symmetric.
…eality

The getter reports whether LiveKit applies native audio configuration.
That is true in every mode except manual, so externalCallSystem should
not read as disabled.
@hiroshihorie
hiroshihorie merged commit 02d1a89 into main Jul 21, 2026
15 checks passed
@hiroshihorie
hiroshihorie deleted the hiroshi/audio-engine-availability branch July 21, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant